💡 AI 인사이트

🤖 AI가 여기에 결과를 출력합니다...

댓글 커뮤니티

쿠팡이벤트

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

검색

    로딩 중이에요... 🐣

    [코담] 웹개발·실전 프로젝트·AI까지, 파이썬·장고의 모든것을 담아낸 강의와 개발 노트

    03 Discord bot | ✅ 저자: 이유정(박사)

    from dotenv import load_dotenv
    # .env 파일에 정의한 환경변수를 불러오는 함수
    load_dotenv()
    
    • dotenv 라이브러리를 써서 프로젝트 최상단에 있는 .env 파일을 읽고,
    • 그 안에 저장된 DISCORD_TOKEN 같은 환경변수들을 파이썬의 os.environ 에 등록해 줍니다.

    import os
    import re
    import discord
    from scraper import MusinsaAPI  
    # MusinsaAPI: 우리가 직접 구현한 웹 크롤러 클래스
    
    • os: 운영체제 관련 기능을 쓰기 위한 표준 모듈 (os.getenv() 로 환경변수 꺼낼 때 사용)
    • re: 정규표현식 라이브러리 (re.match/re.search 로 메시지 패턴을 찾을 때 사용)
    • discord: Discord 봇 API 라이브러리(discord.py)
    • MusinsaAPI: scraper.py 에 정의한 웹 크롤러 클래스. Musinsa 상품 데이터를 가져오는 역할

    # ─── Discord 봇 기본 설정 ─────────────────────────────────────────────────
    intents = discord.Intents.default()
    intents.message_content = True  # 메시지 내용을 읽어오기 위한 권한 설정
    client = discord.Client(intents=intents)  # Discord 클라이언트 인스턴스 생성
    
    • Intents
      • Discord가 어떤 종류의 이벤트(메시지, 반응, 접속 등)를 봇에 전달할지 설정하는 객체
      • message_content=True 를 켜야 채팅 내용(message.content)을 읽어올 수 있어요.
    • Client
      • Discord와 연결해 주고, 이벤트(메시지 수신·봇 준비 완료 등)를 처리할 봇 객체를 만듭니다.

    # ─── Embed 메시지 생성 헬퍼 함수 (개발자 작성) ────────────────────────────────────
    def build_message(item):
        # discord.Embed: 디스코드에 이쁘게 보낼 임베드 메시지 객체 생성 (라이브러리 제공)
        embed = discord.Embed(type="rich", title=item["name"], url=item["url"])
        # set_thumbnail: 임베드에 이미지 축소판을 붙여주는 라이브러리 함수
        embed.set_thumbnail(url=item["image"])
        # description: Embed 객체의 본문 영역에 브랜드명 추가 (직접 작성)
        embed.description = item["brand"]
        # add_field: 필드를 추가해 정가/할인가를 표처럼 표시 (라이브러리 함수)
        embed.add_field(name="정가", value=item["originalPrice"], inline=True)
        embed.add_field(name="할인가", value=item["salePrice"], inline=True)
        return embed
    
    • 임베드(Embed): 카드 형태로 이미지·제목·필드 등을 깔끔하게 보여주는 Discord 메시지 포맷
    • build_message(item) 에서:
      1. Embed(...) 로 객체 만들고
      2. set_thumbnail() 으로 작은 이미지 추가
      3. description 에 브랜드명 표시
      4. add_field() 로 “정가”/“할인가”를 두 개의 칸으로 표시
    • 이렇게 완성된 embed 를 돌려주면, message.channel.send(embed=embed) 만으로 이쁘게 전송됩니다.

    봇 준비 완료 이벤트

    @client.event
    async def on_ready():
    
    • 이 줄은 Discord 이벤트 핸들러를 등록하는 데코레이터입니다.
    • @client.eventdiscord.Client 인스턴스(client)에 이벤트를 연결하겠다는 뜻입니다.
    • 예를 들어, on_ready, on_message, on_member_join 같은 이벤트 이름에 따라 봇의 동작을 정의합니다.

    last_recommendations: dict[int, dict] = {}
    
    • 디스코드 채팅방마다 추천한 상품 목록을 "기억"해두는 저장소입니다.
    • last_recommendations:채팅방별 추천 기록을 저장할 변수
    • dict[int, dict]:숫자(채널 ID)를 키로 하고, 추천결과(딕셔너리)를 값으로 가집니다.
    • {}:지금은 비어있는 상태 (아무 채팅방 정보도 없음)

    @client.event
    async def on_message(message):
        if message.author == client.user:
            return
    
    • 디스코드 봇에서 누가 메시지를 보내면, 아래 함수를 실행해줘! 라는 데코레이터
    • on_message는 디스코드에서 누군가가 채팅을 보냈을 때 실행되는 함수
    • 누가 "신발추천" 이라고 채팅하면 message.content"신발추천"이 들어 있어요
    • 자기 자신(봇)이 보낸 메시지인지 확인하는 조건
    • client.user는 이 디스코드 봇 자체를 뜻하고
    • message.author는 메시지를 보낸 사람(혹은 봇)을 뜻합니다.
    • "이 메시지를 내가(봇이) 보냈으면 무시하자!" 라는 뜻입니다.
    • 봇이 자기 메시지에 반응하면 무한반복 메시지 지옥이 되기 때문이에요

    content = message.content.strip()  
    content_lower = content.lower()  
    channel_id = message.channel.id   
    
    • message.content → 사용자가 보낸 채팅 메시지 내용을 가져옵니다. (예: "신발추천 ")
    • .strip() → 문자열 앞뒤의 공백(띄어쓰기, 줄바꿈 등)을 제거해줍니다.
    • 대소문자 상관없이 명령어를 인식하게 하기 위해 소문자로 통일한 거예요
    • channel_id = message.channel.id:메시지를 보낸 채널의 고유 ID 번호를 가져오는 코드
    • 디스코드 서버 안에 여러 채널이 있을 때, 각각의 채널마다 고유 번호가 있어요.
    • 이 번호를 channel_id라는 변수에 저장하며, 이걸 왜 저장하냐면, 채널마다 따로따로 추천 목록을 기억하려고 (last_recommendations[channel_id] 이런 식으로 사용)

    if (
    	"신발추천" in content_lower
    	or "신발 추천" in content_lower
    	or content_lower == "신발"
    ):
    	await handle_recommendation(channel_id, "신발", message)
    	return
    
    • 사용자가 보낸 메시지(content_lower)에 다음 중 하나라도 포함되면:
      • "신발추천" (붙여쓴 형태)
      • "신발 추천" (띄어쓴 형태)
      • 또는 메시지가 정확히 "신발"인 경우 즉, 유저가 신발 관련 추천을 요청한 경우를 감지합니다. or는 "또는"이라는 뜻으로, 조건 중 하나라도 True면 전체 조건이 True가 됩니다.
    • await는 "잠깐 기다렸다가 결과가 오면 계속해" 라는 뜻

    if any(
    	cmd in content_lower
    	for cmd in ("반팔추천", "반팔 추천", "티셔츠추천", "티셔츠 추천")
    ):
    	await handle_recommendation(
    		channel_id, "반팔", message, icon="👕", title="반팔 추천 TOP5"
            )
            return
    

    content_lower

    • 사용자가 디스코드에 보낸 메시지를 소문자로 바꾼 문자열
    • 예: "티셔츠추천""티셔츠추천" (소문자 그대로 유지)

    ("반팔추천", "반팔 추천", "티셔츠추천", "티셔츠 추천")

    • 사용자가 입력할 수 있는 추천 명령어 목록이에요.
    • 예: "반팔추천" 이나 "티셔츠 추천"

    cmd in content_lower for cmd in ...

    • 저 명령어들 중 하나라도 사용자가 보낸 메시지에 포함돼 있는지 확인해요. any(...)
    • any()는 하나라도 참(True)이면 전체 결과를 True로 만들어줘요.

    즉, 이 조건은 다음과 같아요: 사용자의 메시지가 "반팔추천", "반팔 추천", "티셔츠추천", "티셔츠 추천" 중 하나라도 포함되어 있다면 아래 코드를 실행해줘!라는 뜻입니다.


    detail_match = re.search(r"(\d+)번 상품 상세", content_lower)
    if detail_match:
    	await handle_detail(detail_match, channel_id, message)
    	return
    
    • re.search()는 정규표현식(문자 패턴)을 이용해 문자열에서 특정한 형식을 찾는 함수예요.
    • r"(\d+)번 상품 상세"는 정규표현식인데요: \d+ 숫자가 1개 이상 있는지 확인 (\d+) 괄호로 묶여 있으니 이 숫자를 추출하겠다는 뜻
    • 사용자가 "N번 상품 상세"라고 입력했다면 → 아래 코드 실행!
    • handle_detail()이라는 함수에 다음 정보를 전달해 실행합니다
      • detail_match: "몇 번 상품인지"를 포함한 정규표현식 결과
      • channel_id: 요청한 채팅방의 ID
      • message: 디스코드 메시지 전체 정보 (보낸 사람, 채널 등)

    bookmark_match = re.search(r"(\d+)번 상품 찜", content_lower)
    if bookmark_match:
    	await handle_bookmark(bookmark_match, channel_id, message)
    	return
    
    • 사용자가 "3번 상품 찜"처럼 입력했는지 확인하고,
    • 해당 번호의 상품을 찜 목록에 추가하는 핸들러 함수(handle_bookmark)를 실행합니다. 이후 다른 조건은 더 이상 처리하지 않고 종료합니다.

    if "가장 저렴" in content_lower:
    	await handle_cheapest(channel_id, message)
    	return
    

    사용자가 보낸 메시지를 모두 소문자로 바꾼 content_lower 문자열 안에
    "가장 저렴"이라는 문구가 포함되어 있는지를 확인합니다. 사용자가 "가장 저렴"이라는 문구를 입력하면,
    handle_cheapest()라는 함수(비동기 함수)를 실행하고,
    실행이 끝나면 이 이후의 코드는 실행하지 않고 종료합니다.


    if "다음 페이지" in content_lower:
    	await handle_next_page(channel_id, message)
    	return
    

    사용자가 "다음 페이지"라는 문구를 입력하면, 이 문구를 소문자로 변환한 메시지 (content_lower)에서 찾아서, 해당 채널(channel_id)에서 이전에 추천된 상품 리스트가 있다면, 그 키워드로 다음 페이지의 상품 목록을 새로 불러와서
    디스코드 채팅창에 보여줍니다.


    if content_lower in {"!cgv", "cgv", "영화추천"}:
    	await handle_cgv(message)
    	return
    

    용자가 "!cgv", "cgv", 또는 "영화추천"이라고 입력하면, CGV 영화 정보를 가져오는 함수(handle_cgv)를 실행해서 디스코드 채널에 영화 랭킹을 보여줍니다.

    GREETINGS = {"안녕하세요", "안녕", "안녕하십니까"}
    

    if content_lower in GREETINGS:
    	await message.channel.send(f"{message.author.display_name}님, 안녕하세요! 😊")
    	return
    

    사용자가 "안녕하세요", "안녕", "안녕하십니까" 중 하나를 입력하면,
    해당 사용자의 이름을 포함해 "안녕하세요 😊"라고 인사 메시지를 보내는 코드입니다.


    if content_lower.startswith("$hello"):
    	await message.channel.send("Hello!")
    	return
    

    사용자가 입력한 메시지가 "$hello"로 시작하면,
    디스코드 채널에 "Hello!"라는 인사 메시지를 보냅니다.


    if content_lower.startswith("$hi") or content_lower == "hi":
    	await message.channel.send(f"hi, {message.author.display_name}! 🙂")
    	return
    

    사용자가 "$hi"로 시작하거나 "hi"라고 입력하면, 그 사용자의 이름을 포함해서
    “hi, [사용자 이름]! 🙂” 라는 인사 메시지를 보냅니다.


    async def handle_recommendation(channel_id, keyword, message, size=5, icon="👟", title=None):
        """신발/반팔 등 추천 목록을 가져와서 전송"""
        
        # 1. 첫 페이지부터 시작
        page = 1
    
        # 2. MusinsaAPI를 사용해서 상품 리스트를 가져옴 (예: 신발/반팔 등)
        items = MusinsaAPI(keyword=keyword, page=page, size=size).fetch()
    
        # 3. 해당 채널에 대한 추천 결과를 캐시에 저장함
        #    (다음에 상세보기, 찜하기, 다음페이지 기능 쓸 때 참조)
        last_recommendations[channel_id] = {
            "keyword": keyword, # 검색어
            "page": page, # 현재 페이지
            "items": items # 받아온 상품 목록
        }
    
        # 4. 제목이 있으면 그걸 쓰고, 없으면 기본 형식으로 제목 만들기
        # 예: "👟 신발 추천 TOP5"
        header = title or f"{icon} **{keyword} 추천 TOP{size}**"
    
        # 5. 상품들을 한 줄씩 번호와 함께 정리
        # 출력 예: "1. 나이키 에어포스 – 99,000원"
        body = "\n".join(
            f"{i+1}. {it['name']}{it['salePrice']}" for i, it in enumerate(items)
        )
    
        # 6. Discord 채널에 추천 메시지 전송
        await message.channel.send(f"{header}\n{body}")
    
    

    async def handle_detail(detail_match, channel_id, message):
        """사용자가 요청한 'N번 상품 상세' 명령에 응답하여, 해당 상품의 
        정보를 Embed 형식으로 전송"""
    
        # 추천 목록이 저장된 캐시에 현재 채널의 정보가 없다면
        # 즉, 아직 "신발추천" 또는 "반팔추천" 같은 추천 요청을 하지 않았다면
        if channel_id not in last_recommendations:
            # 유저에게 먼저 추천 명령어를 입력하라고 알림
            await message.channel.send("먼저 상품 추천 후 상세 보기 요청해 주세요.")
            return  # 더 이상 실행하지 않음
    
        # detail_match는 정규표현식 결과로, 2번 상품 상세와 같은 메시지에서 숫자만 추출
        # group(1)은 첫 번째 괄호로 묶인 숫자 → 예: 2번 상품 상세 → group(1)은 2
        # -1을 하는 이유는 Python 리스트의 인덱스는 0부터 시작하기 때문
        idx = int(detail_match.group(1)) - 1
    
        # 이 채널에서 마지막으로 추천된 상품 목록을 가져옴
        items = last_recommendations[channel_id]["items"]
    
        # 인덱스가 0보다 작거나, 목록 길이보다 크거나 같으면 존재하지 않는 항목이므로 오류 처리
        if idx < 0 or idx >= len(items):
            # 유저에게 존재하지 않는 번호라고 안내
            await message.channel.send("해당 번호의 상품이 없습니다.")
            return
    
        # 정상적인 인덱스인 경우,해당 상품 정보를 예쁜Embed 카드로 만들어 전송
        embed = build_message(items[idx])
        await message.channel.send(embed=embed)  
        # 디스코드에 카드 형태로 출력
    

    async def handle_bookmark(bookmark_match, channel_id, message):
        """N번 상품 찜하기 요청을 처리하여 유저에게 알림 메시지를 전송"""
    
        # 이 채널에 대한 추천 목록이 없으면 (즉, 사용자가 먼저 상품 추천을 하지 않았다면)
        if channel_id not in last_recommendations:
            # 추천 먼저 하라는 경고 메시지를 전송
            await message.channel.send("먼저 상품 추천 후 찜하기 요청해 주세요.")
            return # 함수 종료
    
        # 3번 상품 찜 같은 메시지에서 숫자 부분(예: 3)을 추출하고, 리스트 인덱스로 변환 (1→0부터 시작하므로 -1)
        idx = int(bookmark_match.group(1)) - 1
    
        # 이 채널에서 저장된 추천 상품 목록을 꺼내옴
        items = last_recommendations[channel_id]["items"]
    
        # 인덱스가 음수이거나, 목록 범위를 벗어나면 잘못된 번호이므로 오류 처리
        if idx < 0 or idx >= len(items):
            await message.channel.send("해당 번호의 상품이 없습니다.")
            return  # 함수 종료
    
        # 유효한 인덱스일 경우 해당 상품을 가져옴
        fav = items[idx]
    
        # TODO: 나중에 DB나 Redis 같은 저장소에 찜한 상품을 저장하는 로직을 여기에 추가할 수 있음
        # 현재는 단순히 찜했다는 메시지만 유저에게 전송
        await message.channel.send(f"`{fav['name']}` 상품을 찜 목록에 추가했어요!")
    

    async def handle_cheapest(channel_id, message):
        """가장 저렴한 상품을 찾아서 사용자에게 전송하는 함수"""
    
        # 채널별 추천 목록이 저장된 캐시에 현재 채널 ID가 없으면,
        # 즉, 사용자가 추천을 먼저 요청하지 않았다면 오류 메시지를 보냄
        if channel_id not in last_recommendations:
            await message.channel.send("먼저 상품 추천 후 가장 저렴 요청해 주세요.")
            return  # 함수 종료
    
        # 현재 채널에 저장된 추천 상품 리스트를 꺼내옴
        items = last_recommendations[channel_id]["items"]
    
        # 상품 가격 문자열에서 원과 ','를 제거하고 정수형으로 변환하는 헬퍼 함수 정의
        # 예: "45,000원" → 45000
        def parse_price(x):
            return int(x["salePrice"].replace("원", "").replace(",", ""))
    
        # 상품 리스트 중 가장 가격이 낮은 상품을 찾음
        # min() 함수는 리스트에서 최솟값을 찾는 함수이며, 가격 기준으로 비교
        cheapest = min(items, key=parse_price)
    
        # 가장 저렴한 상품의 이름과 가격을 메시지로 전송
        await message.channel.send(
            f"💸 **가장 저렴한 상품**:\n{cheapest['name']}{cheapest['salePrice']}"
        )
    
    

    # 비동기 함수로, 사용자가 "다음 페이지"라고 입력했을 때 다음 상품 목록을 가져오는 역할
    async def handle_next_page(channel_id, message, size=5):
        """다음 페이지 상품 목록을 가져와 전송"""
    
        # 먼저 이 채널에서 추천받은 기록이 있는지 확인
        if channel_id not in last_recommendations:
            # 없다면 오류 메시지를 보내고 함수 종료
            await message.channel.send("먼저 상품 추천 후 다음 페이지 요청해 주세요.")
            return
    
        # 이전에 추천한 데이터(키워드, 현재 페이지, 상품 리스트)를 가져옴
        cache = last_recommendations[channel_id]
    
        # 현재 페이지보다 하나 큰 페이지 번호 계산 → 다음 페이지
        next_page = cache["page"] + 1
    
        # MusinsaAPI 클래스의 fetch() 메서드를 통해 새로운 상품 리스트를 가져옴
        # 기존 검색 키워드(cache["keyword"])를 그대로 사용하고, 다음 페이지로 넘김
        items = MusinsaAPI(keyword=cache["keyword"], page=next_page, size=size).fetch()
    
        # 캐시(메모리 저장소)에 현재 채널에 대한 추천 목록 정보를 업데이트
        # 사용자가 다음 페이지를 또 요청할 수 있으니 현재 페이지 번호, 검색어, 아이템 목록을 저장
        last_recommendations[channel_id] = {
            "keyword": cache["keyword"],
            "page": next_page,
            "items": items,
        }
    
        # 화면에 보여줄 상품 목록 텍스트 생성
        # 예: 1. 상품명 – 가격\n 2. 상품명 – 가격 ...
        body = "\n".join(
            f"{i+1}. {it['name']}{it['salePrice']}" for i, it in enumerate(items)
        )
    
        # 유저에게 다음 페이지 결과를 전송
        # ex) "신발 검색 2페이지" 제목과 함께 목록을 보여줌
        await message.channel.send(
            f"➡️ **{cache['keyword']} 검색 {next_page}페이지**\n{body}"
        )
    

    # 비동기 함수로, 사용자가 "!cgv", "cgv", "영화추천" 등의 메시지를 보냈을 때 실행됨
    # CGV 예매율 상위 N개(기본값 5개)를 가져와 메시지로 전송함
    async def handle_cgv(message, limit=5):
        """CGV 영화 예매율 TOPN 출력"""
    
        # 외부에서 구현된 함수 get_cgv_movies()를 호출해 영화 데이터를 가져옴
        # 반환값은 영화 정보가 담긴 딕셔너리들의 리스트
        movies = get_cgv_movies()
    
        # 영화 데이터가 없거나 가져오기 실패한 경우
        if not movies:
            # 사용자에게 오류 메시지를 보냄
            await message.channel.send("CGV 영화 정보를 가져올 수 없습니다.")
            return
    
        # 영화리스트 중 상위 limit개(기본 5개)를 순회하면서 메시지 줄들을 생성
        # 각 줄은 "1. 영화제목 (개봉일) - 예매율: XX%" 형식으로 표시
        lines = [
            f"{i+1}. {m['title']} ({m['release_date']}) - 예매율: {m['reserve_percent']}"
            for i, m in enumerate(movies[:limit])
        ]
    
        # 위에서 만든 줄들을 \n 줄바꿈으로 이어붙여 사용자에게 전송
        # 메시지 앞에는 🎬 이모지와 제목도 함께 보냄
        await message.channel.send(f"🎬 **CGV 예매율 TOP{limit}**\n" + "\n".join(lines))
    

    if __name__ == "__main__":
        # .env에서 읽어온 토큰 가져오기 (load_dotenv 덕분)
        token = os.getenv("DISCORD_TOKEN")
        if not token:
            # 없으면 실행 중단하고 에러 알리기 (개발자 작성)
            raise RuntimeError("DISCORD_TOKEN 환경변수가 설정되지 않았습니다.")
        # client.run: Discord 서버에 로그인/연결을 시도 (라이브러리 함수)
        client.run(token)
    
    TOP
    preload preload